NexusPi Git Node
Commit 11771814e32f88d10f68c903e84f97f044a5974e
Parents : 18a1407
Author : James L <jrl290@gmail.com>
Date : 2026-04-26T19:23:01-04:00
Add configurable Node Name to portal, display, and advertisement
- BoundaryMode.h: add ADDR_CONF_NODE_NAME (0x12E, 33 bytes) EEPROM slot,
node_name[33] field to BoundaryState, load/save in boundary_load/save_config()
- Display.h: forward-decl copy of BoundaryState updated with node_name;
title bar shows node_name when set, falls back to 'RTNode'
- BoundaryConfig.h: new 'Node Name' section at top of settings form
(pre-populated, maxlength 32); config_handle_save() reads and persists it
- Advertise.h: ADV_FIELD_NAME uses node_name when non-empty, otherwise
auto-generates 'RTNode-<hash prefix>' as before
Changes
10 files changed, 370 insertions(+), 167 deletions(-)
Diff
diff --git a/Advertise.h b/Advertise.h
old mode 100644
new mode 100755
index b26d537..f4d6cbb
--- a/Advertise.h
+++ b/Advertise.h
@@ -363,16 +363,22 @@ static RNS::Bytes advertise_build_info() {
adv_mp_bin(packed, tid_hash.data(), tid_hash.size());
}
- // NAME (str) — discovery_name. Use the cached node hash hex prefix when no
- // explicit name has been configured, so each node has a unique identifier
- // visible on maps.
+ // NAME (str) — discovery_name. Use the user-configured node name when set,
+ // otherwise fall back to a prefix of the node hash hex so each node has a
+ // unique identifier visible on maps.
{
char name_buf[40];
- const char* hex = (rtc_node_hash_magic == NODE_HASH_RTC_MAGIC && rtc_node_hash_hex[0] != '\0')
- ? rtc_node_hash_hex : "";
- snprintf(name_buf, sizeof(name_buf), "RTNode-%.8s", hex[0] ? hex : "unknown");
+ const char* adv_name;
+ if (boundary_state.node_name[0] != '\0') {
+ adv_name = boundary_state.node_name;
+ } else {
+ const char* hex = (rtc_node_hash_magic == NODE_HASH_RTC_MAGIC && rtc_node_hash_hex[0] != '\0')
+ ? rtc_node_hash_hex : "";
+ snprintf(name_buf, sizeof(name_buf), "RTNode-%.8s", hex[0] ? hex : "unknown");
+ adv_name = name_buf;
+ }
adv_mp_key(packed, ADV_FIELD_NAME);
- adv_mp_str(packed, name_buf);
+ adv_mp_str(packed, adv_name);
}
// LATITUDE / LONGITUDE (float64) — apply optional privacy jitter.
diff --git a/BoundaryConfig.h b/BoundaryConfig.h
index 4dbba20..2971f18 100755
--- a/BoundaryConfig.h
+++ b/BoundaryConfig.h
@@ -150,6 +150,19 @@ static void config_send_html() {
html += F("</code></div>");
html += F("<form method='POST' action='/save'>");
+
+ // ── Node Name Section ──
+ html += F(
+ "<h2>🏷 Node Name</h2>"
+ "<p class='note'>A human-readable name for this node, shown in advertisements and on maps "
+ "such as <a href='https://rmap.world' target='_blank' style='color:#7ecfff'>rmap.world</a>. "
+ "Leave blank to auto-generate a name from the node hash.</p>"
+ "<label>Name</label>"
+ "<input name='node_name' maxlength='32' placeholder='e.g. My RNode' value='"
+ );
+ html += String(boundary_state.node_name);
+ html += F("'>");
+
html += F(
"<h2>📶 WiFi Network</h2>"
"<label>WiFi</label>"
@@ -369,36 +382,6 @@ static void config_send_html() {
html += F("<p class='note'>Decimal degrees, signed. North/East positive, South/West negative. "
"Leave both blank to omit coordinates.</p>");
- // Browser geolocation helper button. Geolocation may be blocked on
- // plain HTTP origins by some browsers; the script reports an error
- // inline if the request fails or is denied.
- html += F(
- "<button type='button' id='geo_btn' "
- "style='width:100%;padding:10px;margin:4px 0 6px;background:#0f3460;"
- "color:#fff;border:none;border-radius:4px;font-size:0.95em;cursor:pointer;'>"
- "📍 Use Browser Location</button>"
- "<p id='geo_status' class='note' style='min-height:1em;'></p>"
- "<script>"
- "(function(){"
- "var btn=document.getElementById('geo_btn');"
- "var st=document.getElementById('geo_status');"
- "if(!btn)return;"
- "btn.addEventListener('click',function(){"
- "if(!('geolocation' in navigator)){"
- "st.textContent='Geolocation not supported by this browser.';return;}"
- "st.textContent='Requesting location\\u2026';"
- "navigator.geolocation.getCurrentPosition(function(pos){"
- "document.getElementById('advert_lat').value=pos.coords.latitude.toFixed(6);"
- "document.getElementById('advert_lon').value=pos.coords.longitude.toFixed(6);"
- "st.textContent='Location filled (\\u00b1'+Math.round(pos.coords.accuracy)+' m).';"
- "},function(err){"
- "st.textContent='Could not get location: '+err.message;"
- "},{enableHighAccuracy:true,timeout:15000,maximumAge:0});"
- "});"
- "})();"
- "</script>"
- );
-
html += F("<label>Randomize Offset</label>"
"<select name='advert_jitter'>");
html += F("<option value='0'");
@@ -594,6 +577,12 @@ static void config_handle_save() {
boundary_state.advert_jitter = (config_server->arg("advert_jitter").toInt() == 1);
+ // ── Node name ──
+ String node_name_arg = config_server->arg("node_name");
+ node_name_arg.trim();
+ memset(boundary_state.node_name, 0, sizeof(boundary_state.node_name));
+ strncpy(boundary_state.node_name, node_name_arg.c_str(), sizeof(boundary_state.node_name) - 1);
+
// Save boundary config to EEPROM
boundary_save_config();
diff --git a/BoundaryMode.h b/BoundaryMode.h
index df42fd8..7d4a2f3 100755
--- a/BoundaryMode.h
+++ b/BoundaryMode.h
@@ -97,7 +97,8 @@
#define ADDR_CONF_ADVERT_LAT 0x11D // Latitude as IEEE-754 double (8 bytes, host byte order)
#define ADDR_CONF_ADVERT_LON 0x125 // Longitude as IEEE-754 double (8 bytes, host byte order)
#define ADDR_CONF_ADVERT_JITTER 0x12D // Randomize ~0.5 km offset flag (1 byte, 0x73 = enabled)
-// Total: 0x12E (302 bytes — extends beyond 256-byte CONFIG area into
+#define ADDR_CONF_NODE_NAME 0x12E // Node display name (33 bytes, null-terminated)
+// Total: 0x14F (335 bytes — extends beyond 256-byte CONFIG area into
// unused EEPROM gap; safe on ESP32 where EEPROM starts at 824)
#define BOUNDARY_ENABLE_BYTE 0x73
@@ -134,6 +135,7 @@ struct BoundaryState {
double advert_lat; // Latitude in decimal degrees (-90..90)
double advert_lon; // Longitude in decimal degrees (-180..180)
bool advert_jitter; // Randomize ~0.5 km offset for advertised coords
+ char node_name[33]; // Human-readable name (empty = auto from node hash)
// Runtime state
bool wifi_connected;
@@ -220,6 +222,7 @@ inline void boundary_load_config() {
boundary_state.advert_lat = 0.0;
boundary_state.advert_lon = 0.0;
boundary_state.advert_jitter = false;
+ boundary_state.node_name[0] = '\0';
// Mark as enabled since we're compiled with BOUNDARY_MODE
boundary_state.enabled = true;
return;
@@ -319,6 +322,12 @@ inline void boundary_load_config() {
uint8_t advert_jitter_byte = EEPROM.read(config_addr(ADDR_CONF_ADVERT_JITTER));
boundary_state.advert_jitter = (advert_jitter_byte == BOUNDARY_ENABLE_BYTE);
+
+ for (int i = 0; i < 32; i++) {
+ boundary_state.node_name[i] = EEPROM.read(config_addr(ADDR_CONF_NODE_NAME + i));
+ if (boundary_state.node_name[i] == (char)0xFF) boundary_state.node_name[i] = '\0';
+ }
+ boundary_state.node_name[32] = '\0';
}
// Reset runtime state
@@ -377,6 +386,10 @@ inline void boundary_save_config() {
boundary_write_double(ADDR_CONF_ADVERT_LON, boundary_state.advert_lon);
EEPROM.write(config_addr(ADDR_CONF_ADVERT_JITTER),
boundary_state.advert_jitter ? BOUNDARY_ENABLE_BYTE : 0x00);
+ for (int i = 0; i < 32; i++) {
+ EEPROM.write(config_addr(ADDR_CONF_NODE_NAME + i), boundary_state.node_name[i]);
+ }
+ EEPROM.write(config_addr(ADDR_CONF_NODE_NAME + 32), 0x00);
EEPROM.write(config_addr(ADDR_CONF_APP_MARKER0), BOUNDARY_APP_MARKER0);
EEPROM.write(config_addr(ADDR_CONF_APP_MARKER1), BOUNDARY_APP_MARKER1);
diff --git a/Config.h b/Config.h
index 29ff2a0..d09ea31 100755
--- a/Config.h
+++ b/Config.h
@@ -73,7 +73,7 @@
// MCU independent configuration parameters
#ifdef BOUNDARY_MODE
- const long serial_baudrate = 921600;
+ const long serial_baudrate = 115200;
#else
const long serial_baudrate = 115200;
#endif
diff --git a/Display.h b/Display.h
index 729876e..b5d62f2 100755
--- a/Display.h
+++ b/Display.h
@@ -60,6 +60,12 @@ struct BoundaryState {
bool ifac_enabled;
char ifac_netname[33];
char ifac_passphrase[33];
+ // Device advertisement settings
+ bool advert_enabled;
+ double advert_lat;
+ double advert_lon;
+ bool advert_jitter;
+ char node_name[33]; // Human-readable name (empty = auto from node hash)
bool wifi_connected;
bool tcp_connected; // Backbone (WAN) connected
bool ap_tcp_connected; // Local TCP server (LAN) has client
@@ -944,7 +950,11 @@ void draw_disp_area() {
disp_area.setTextColor(SSD1306_BLACK);
disp_area.setTextSize(1);
disp_area.setCursor(4, 7);
- disp_area.print("RTNode");
+ if (boundary_state.node_name[0] != '\0') {
+ disp_area.print(boundary_state.node_name);
+ } else {
+ disp_area.print("RTNode");
+ }
disp_area.setTextColor(SSD1306_WHITE);
diff --git a/Makefile b/Makefile
index 8ba24a9..1a68229 100755
--- a/Makefile
+++ b/Makefile
@@ -282,6 +282,26 @@ release: release-all
release-all: console-site spiffs-image release-tbeam release-tbeam_sx1262 release-lora32_v10 release-lora32_v20 release-lora32_v21 release-lora32_v10_extled release-lora32_v20_extled release-lora32_v21_extled release-lora32_v21_tcxo release-featheresp32 release-genericesp32 release-heltec32_v2 release-heltec32_v3 release-heltec32_v4 release-heltec32_v2_extled release-heltec_t114 release-techo release-rnode_ng_20 release-rnode_ng_21 release-t3s3 release-t3s3_sx127x release-t3s3_sx1280_pa release-tdeck release-tbeam_supreme release-rak4631 release-xiao_s3 release-hashes
+# Build all PlatformIO environments and package them into a single release archive.
+# The archive (rtnode_firmware.zip) is the primary distribution artefact consumed
+# by flash.py. Individual binaries are stored flat inside the zip.
+release-pio:
+ pio run -e heltec_V4_boundary -e heltec_V4_boundary_16mb -e heltec_V3_boundary
+ python3 -c "\
+import zipfile, os, sys; \
+variants = [ \
+ ('.pio/build/heltec_V4_boundary', 'rnode_firmware_heltec32v4_boundary_8mb.bin'), \
+ ('.pio/build/heltec_V4_boundary_16mb', 'rnode_firmware_heltec32v4_boundary_16mb.bin'), \
+ ('.pio/build/heltec_V3_boundary', 'rnode_firmware_heltec32v3.bin'), \
+]; \
+missing = [(d,n) for d,n in variants if not os.path.isfile(os.path.join(d,n))]; \
+[sys.exit(f'Missing: {os.path.join(d,n)}') for d,n in missing]; \
+zf = zipfile.ZipFile('rtnode_firmware.zip','w',zipfile.ZIP_DEFLATED); \
+[zf.write(os.path.join(d,n), n) for d,n in variants]; \
+zf.close(); \
+print('Created rtnode_firmware.zip with', len(variants), 'variants'); \
+"
+
release-hashes:
python ./release_hashes.py > ./Release/release.json
diff --git a/flash.py b/flash.py
index 05062c0..af098cf 100755
--- a/flash.py
+++ b/flash.py
@@ -61,7 +61,8 @@ FLASH_FREQ = "80m"
GITHUB_REPO = "jrl290/RTNode-HeltecV4"
# Runtime state (set automatically during main())
-_flash_mode_override = None # CLI --flash-mode sets this; otherwise board profile wins
+_flash_mode_override = None # CLI --flash-mode sets this; otherwise board profile wins
+_detected_flash_size = None # Actual flash size read from device; overrides board profile
_esptool_write_verify_support = {}
# Flash addresses for ESP32-S3 Arduino framework
@@ -74,26 +75,43 @@ APP_ADDR = 0x10000
# Each board defines its PIO env, flash size, baud rate, firmware binary name,
# and merged binary name.
+# Single archive name released on GitHub — contains every board/flash-size variant.
+FIRMWARE_ARCHIVE = "rtnode_firmware.zip"
+# Conservative default when flash size can't be detected: 8MB firmware runs on
+# any device (8MB or larger); a 16MB image on an 8MB device crashes at boot.
+DEFAULT_FLASH_SIZE = "8MB"
+
BOARD_PROFILES = {
"v4": {
- "name": "Heltec WiFi LoRa 32 V4",
- "pio_env": "heltec_V4_boundary",
- "build_dir": ".pio/build/heltec_V4_boundary",
- "firmware_bin": "rnode_firmware_heltec32v4_boundary.bin",
- "merged_filename": "rtnode_heltec_v4.bin",
- "flash_size": "16MB",
- "baud_rate": "921600",
- "flash_mode": "dio", # DIO is universally compatible with all flash chips
+ "name": "Heltec WiFi LoRa 32 V4",
+ "chip": "ESP32-S3", # matches esptool "Chip is ESP32-S3 ..."
+ "baud_rate": "921600",
+ "flash_mode": "qio", # ESP32-S3 PSRAM requires QIO — DIO disables PSRAM
+ "flash_variants": {
+ "8MB": {
+ "pio_env": "heltec_V4_boundary",
+ "build_dir": ".pio/build/heltec_V4_boundary",
+ "firmware_bin": "rnode_firmware_heltec32v4_boundary_8mb.bin",
+ },
+ "16MB": {
+ "pio_env": "heltec_V4_boundary_16mb",
+ "build_dir": ".pio/build/heltec_V4_boundary_16mb",
+ "firmware_bin": "rnode_firmware_heltec32v4_boundary_16mb.bin",
+ },
+ },
},
"v3": {
- "name": "Heltec WiFi LoRa 32 V3",
- "pio_env": "heltec_V3_boundary",
- "build_dir": ".pio/build/heltec_V3_boundary",
- "firmware_bin": "rnode_firmware_heltec32v3.bin",
- "merged_filename": "rtnode_heltec_v3.bin",
- "flash_size": "8MB",
- "baud_rate": "460800",
- "flash_mode": "dio", # V3 uses DIO — some flash chips do not support QIO
+ "name": "Heltec WiFi LoRa 32 V3",
+ "chip": "ESP32", # matches esptool "Chip is ESP32 ..."
+ "baud_rate": "460800",
+ "flash_mode": "dio",
+ "flash_variants": {
+ "8MB": {
+ "pio_env": "heltec_V3_boundary",
+ "build_dir": ".pio/build/heltec_V3_boundary",
+ "firmware_bin": "rnode_firmware_heltec32v3.bin",
+ },
+ },
},
}
DEFAULT_BOARD = "v4"
@@ -104,8 +122,22 @@ _board = None
def board_profile():
return BOARD_PROFILES[_board or DEFAULT_BOARD]
+def flash_variant():
+ """Return the flash variant dict for the active board and detected flash size.
+
+ Falls back to the smallest (safest) available variant when the exact size is
+ unknown — a smaller-flash firmware runs on any larger device, but not vice versa.
+ """
+ variants = board_profile()["flash_variants"]
+ size = _detected_flash_size or DEFAULT_FLASH_SIZE
+ if size in variants:
+ return variants[size]
+ # Fallback: smallest available variant
+ available = sorted(variants.keys(), key=lambda s: int(s.replace("MB", "")))
+ return variants[available[0]]
+
def BUILD_DIR():
- return board_profile()["build_dir"]
+ return flash_variant()["build_dir"]
def BOOTLOADER_BIN():
return os.path.join(BUILD_DIR(), "bootloader.bin")
@@ -114,10 +146,11 @@ def PARTITIONS_BIN():
return os.path.join(BUILD_DIR(), "partitions.bin")
def FIRMWARE_BIN():
- return os.path.join(BUILD_DIR(), board_profile()["firmware_bin"])
+ return os.path.join(BUILD_DIR(), flash_variant()["firmware_bin"])
def FLASH_SIZE():
- return board_profile()["flash_size"]
+ """Return the effective flash size: detected from device, or conservative default."""
+ return _detected_flash_size or DEFAULT_FLASH_SIZE
def BAUD_RATE():
return board_profile()["baud_rate"]
@@ -129,11 +162,8 @@ def BOARD_FLASH_MODE():
"""
return _flash_mode_override or board_profile().get("flash_mode", FLASH_MODE)
-def MERGED_FILENAME():
- return board_profile()["merged_filename"]
-
def PIO_ENV():
- return board_profile()["pio_env"]
+ return flash_variant()["pio_env"]
# ESP32 partition table magic bytes (first two bytes of a partition table entry)
PARTITION_TABLE_MAGIC = b'\xaa\x50'
@@ -263,22 +293,17 @@ BOOT_APP0_BIN = find_boot_app0()
# ── Board auto-detection ───────────────────────────────────────────────────────
-# Map detected flash sizes to board keys
-_FLASH_SIZE_TO_BOARD = {
- "16MB": "v4",
- "8MB": "v3",
+# Map chip type to board keys. Chip string comes from esptool "Chip is <type>".
+# Sorted longest-first in detect_board so "ESP32-S3" wins over "ESP32".
+_CHIP_TO_BOARD = {
+ "ESP32-S3": "v4",
+ "ESP32": "v3",
}
-def detect_board(port, esptool_cmd):
- """Auto-detect which Heltec board is connected by querying flash size.
-
- Runs ``esptool.py flash_id`` and parses the output for:
- - Detected flash size (16MB → V4, 8MB → V3)
- - Chip type (ESP32-S3 expected)
- - Features (PSRAM size, WiFi, BLE)
+def read_flash_info(port, esptool_cmd):
+ """Read device flash metadata using ``esptool.py flash_id``.
- Returns a tuple (board_key, info_dict) on success, or (None, reason) on
- failure. ``board_key`` is "v3" or "v4".
+ Returns ``(info_dict, None)`` on success, or ``(None, reason)`` on failure.
"""
cmd = esptool_cmd + ["--port", port, "flash_id"]
try:
@@ -312,10 +337,40 @@ def detect_board(port, esptool_cmd):
if not flash_size:
return None, f"Could not parse flash size from esptool output:\n{output.strip()}"
- board_key = _FLASH_SIZE_TO_BOARD.get(flash_size)
- if not board_key:
+ return info, None
+
+
+def detect_board(port, esptool_cmd):
+ """Auto-detect which Heltec board is connected.
+
+ Uses chip type as the primary discriminator (ESP32-S3 → V4, ESP32 → V3),
+ so a V4 device with 8MB flash is correctly identified as V4, not V3.
+ Flash size is stored in the returned info dict and used to select the
+ correct firmware variant.
+
+ Returns a tuple (board_key, info_dict) on success, or (None, reason) on
+ failure. ``board_key`` is "v3" or "v4".
+ """
+ info, err = read_flash_info(port, esptool_cmd)
+ if not info:
+ return None, err
+
+ chip_str = info.get("chip", "")
+ features = info.get("features", "")
+
+ if "ESP32-S3" in chip_str:
+ # Both V3 and V4 are ESP32-S3. Distinguish by PSRAM presence.
+ # V4 (ESP32-S3FH4R2): features includes "Embedded PSRAM"
+ # V3 (ESP32-S3FN8): features has no PSRAM entry
+ if "PSRAM" in features.upper():
+ board_key = "v4"
+ else:
+ board_key = "v3"
+ elif "ESP32" in chip_str:
+ board_key = "v3"
+ else:
return None, (
- f"Unknown flash size '{flash_size}' — expected 16MB (V4) or 8MB (V3).\n"
+ f"Unknown chip '{chip_str}' — expected ESP32-S3 (V3/V4) or ESP32.\n"
f"Use --board v3 or --board v4 to specify manually."
)
@@ -345,6 +400,16 @@ def find_esptool(prefer_system=False):
repo_candidates = []
if has_pyserial:
+ # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ # DO NOT CHANGE THIS ORDER.
+ # The bundled Release/esptool is INTENTIONALLY first.
+ # It is pinned to a specific version for release reproducibility —
+ # users flashing from a release ZIP get the same esptool regardless
+ # of what is installed on their machine.
+ # PlatformIO esptool is a fallback only, for dev environments where
+ # the bundled copy is absent or broken.
+ # NEVER reorder these lines. NEVER "prefer" PlatformIO over bundled.
+ # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
if os.path.isfile(bundled):
repo_candidates.append(([sys.executable, bundled], f"bundled esptool: {bundled}"))
if os.path.isfile(pio_esptool):
@@ -465,37 +530,40 @@ def _cache_dir():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), ".firmware_cache")
-def _cache_meta_path(board_key):
- """Return path to the cache metadata JSON for a given board."""
- return os.path.join(_cache_dir(), board_key, "meta.json")
+def _archive_cache_path():
+ """Return path to the cached firmware archive zip."""
+ return os.path.join(_cache_dir(), FIRMWARE_ARCHIVE)
+
+def _extracted_firmware_path(firmware_name):
+ """Return path to an extracted firmware binary in the flat cache dir."""
+ return os.path.join(_cache_dir(), firmware_name)
-def _cached_firmware_path(board_key):
- """Return path to the cached firmware binary for a given board."""
- return os.path.join(_cache_dir(), board_key, BOARD_PROFILES[board_key]["merged_filename"])
+def _cache_meta_path():
+ """Return path to the archive cache metadata JSON (single file for all variants)."""
+ return os.path.join(_cache_dir(), "meta.json")
-def _read_cache_meta(board_key):
- """Read cache metadata, returning dict or None if not cached."""
+
+def _read_cache_meta():
+ """Read archive cache metadata, returning dict or None if not cached."""
import json
- meta_path = _cache_meta_path(board_key)
- if os.path.isfile(meta_path):
+ path = _cache_meta_path()
+ if os.path.isfile(path):
try:
- with open(meta_path) as f:
+ with open(path) as f:
return json.load(f)
except Exception:
pass
return None
-def _write_cache_meta(board_key, tag, sha256):
- """Write cache metadata after a successful download."""
+def _write_cache_meta(tag, sha256):
+ """Write archive cache metadata after a successful download."""
import json
- cache = os.path.join(_cache_dir(), board_key)
- os.makedirs(cache, exist_ok=True)
- meta = {"tag": tag, "sha256": sha256}
- with open(_cache_meta_path(board_key), "w") as f:
- json.dump(meta, f, indent=2)
+ os.makedirs(_cache_dir(), exist_ok=True)
+ with open(_cache_meta_path(), "w") as f:
+ json.dump({"tag": tag, "sha256": sha256}, f, indent=2)
def _parse_version_tag(tag):
@@ -532,21 +600,28 @@ def _fetch_release_info(tag=None):
return None, str(e)
-def fetch_firmware(board_key, release_tag=None):
- """Fetch firmware from GitHub, using cache when possible.
+def fetch_firmware(board_key, flash_size, release_tag=None):
+ """Fetch firmware from the GitHub release archive, using cache when possible.
- Logic:
- 1. Query GitHub for the target release (latest or specific tag).
- 2. If the cached firmware matches that release tag, skip download.
- 3. Otherwise download the merged firmware binary and update cache.
+ Downloads ``rtnode_firmware.zip`` once and extracts the correct binary for
+ the given board/flash-size combination. Falls back gracefully to the old
+ per-board binary if the archive is not present in the release (backward compat).
Returns (firmware_path, release_tag) on success, (None, reason) on failure.
"""
+ import zipfile
from urllib.request import urlretrieve
- merged_name = BOARD_PROFILES[board_key]["merged_filename"]
- cache_path = _cached_firmware_path(board_key)
- cache_meta = _read_cache_meta(board_key)
+ # Resolve variant (fallback to smallest/safest if exact size missing)
+ variants = BOARD_PROFILES[board_key]["flash_variants"]
+ if flash_size not in variants:
+ flash_size = sorted(variants, key=lambda s: int(s.replace("MB", "")))[0]
+ print(f" ⚠ No {flash_size} variant for {board_key} — using {flash_size}")
+ variant = variants[flash_size]
+ firmware_name = variant["firmware_bin"]
+ extracted_path = _extracted_firmware_path(firmware_name)
+ archive_path = _archive_cache_path()
+ cache_meta = _read_cache_meta()
# 1. Fetch release info
label = f"release {release_tag}" if release_tag else "latest release"
@@ -554,23 +629,21 @@ def fetch_firmware(board_key, release_tag=None):
release, err = _fetch_release_info(release_tag)
if not release:
print(f" Could not reach GitHub: {err}")
- # Fall back to cache if available
- if cache_meta and os.path.isfile(cache_path):
- print(f" Using cached firmware: {cache_meta['tag']}")
- return cache_path, cache_meta["tag"]
+ if cache_meta and os.path.isfile(extracted_path):
+ print(f" Using cached firmware: {cache_meta.get('tag', '?')}")
+ return extracted_path, cache_meta.get("tag", "cached")
return None, f"No cached firmware and GitHub unreachable: {err}"
remote_tag = release.get("tag_name", "unknown")
- # 2. Check cache
- if cache_meta and os.path.isfile(cache_path):
+ # 2. Check whether cached archive is still valid
+ need_download = True
+ if cache_meta and os.path.isfile(archive_path):
cached_tag = cache_meta.get("tag")
if cached_tag == remote_tag:
- # Verify file integrity
- actual_sha = sha256_file(cache_path)
- if actual_sha == cache_meta.get("sha256"):
- print(f" Cached firmware is up-to-date: {remote_tag}")
- return cache_path, remote_tag
+ if sha256_file(archive_path) == cache_meta.get("sha256"):
+ print(f" Cached firmware archive is up-to-date: {remote_tag}")
+ need_download = False
else:
print(f" Cache integrity mismatch — re-downloading")
else:
@@ -583,33 +656,67 @@ def fetch_firmware(board_key, release_tag=None):
else:
print(f" Version changed: {cached_tag} → {remote_tag}")
- # 3. Find the asset URL
- asset_url = None
- for asset in release.get("assets", []):
- if asset["name"] == merged_name:
- asset_url = asset["browser_download_url"]
- break
-
- if not asset_url:
- available = [a["name"] for a in release.get("assets", [])]
- return None, (
- f"'{merged_name}' not found in release {remote_tag}.\n"
- f" Available assets: {available}"
- )
+ if need_download:
+ # 3. Locate the archive asset (with per-board fallback for old releases)
+ asset_url = None
+ fallback_url = None
+ fallback_name = None
+ for asset in release.get("assets", []):
+ if asset["name"] == FIRMWARE_ARCHIVE:
+ asset_url = asset["browser_download_url"]
+ if asset["name"] == firmware_name:
+ fallback_url = asset["browser_download_url"]
+ fallback_name = asset["name"]
+
+ os.makedirs(_cache_dir(), exist_ok=True)
+
+ if asset_url:
+ print(f" Downloading {remote_tag} / {FIRMWARE_ARCHIVE}...")
+ try:
+ urlretrieve(asset_url, archive_path)
+ except Exception as e:
+ return None, f"Download failed: {e}"
+ file_sha = sha256_file(archive_path)
+ _write_cache_meta(remote_tag, file_sha)
+ print(f" Downloaded {os.path.getsize(archive_path):,} bytes SHA-256: {file_sha[:16]}...")
+
+ elif fallback_url:
+ # Old-style release — download the individual binary directly
+ print(f" Archive '{FIRMWARE_ARCHIVE}' not in release — downloading {fallback_name}")
+ try:
+ urlretrieve(fallback_url, extracted_path)
+ except Exception as e:
+ return None, f"Download failed: {e}"
+ file_sha = sha256_file(extracted_path)
+ _write_cache_meta(remote_tag, file_sha)
+ print(f" Downloaded {os.path.getsize(extracted_path):,} bytes SHA-256: {file_sha[:16]}...")
+ return extracted_path, remote_tag
- # 4. Download
- os.makedirs(os.path.join(_cache_dir(), board_key), exist_ok=True)
- print(f" Downloading {remote_tag} / {merged_name}...")
+ else:
+ available = [a["name"] for a in release.get("assets", [])]
+ return None, (
+ f"Neither '{FIRMWARE_ARCHIVE}' nor '{firmware_name}' found in release {remote_tag}.\n"
+ f" Available assets: {available}"
+ )
+
+ # 4. Extract the correct variant from the archive
+ if not os.path.isfile(archive_path):
+ return None, f"Archive not found: {archive_path}"
try:
- urlretrieve(asset_url, cache_path)
+ with zipfile.ZipFile(archive_path) as zf:
+ names = zf.namelist()
+ if firmware_name not in names:
+ return None, (
+ f"'{firmware_name}' not found in archive.\n"
+ f" Archive contains: {names}"
+ )
+ with zf.open(firmware_name) as src, open(extracted_path, "wb") as dst:
+ dst.write(src.read())
+ print(f" Extracted {firmware_name}")
except Exception as e:
- return None, f"Download failed: {e}"
+ return None, f"Failed to extract firmware from archive: {e}"
- file_sha = sha256_file(cache_path)
- file_size = os.path.getsize(cache_path)
- _write_cache_meta(board_key, remote_tag, file_sha)
- print(f" Downloaded {file_size:,} bytes SHA-256: {file_sha[:16]}...")
- return cache_path, remote_tag
+ return extracted_path, remote_tag
def _do_merge(output_path, esptool_cmd, bootloader, partitions, boot_app0, firmware):
@@ -991,7 +1098,7 @@ def _monitor_boot(port, timeout=8):
ser.close()
return False, output
# Any application output means boot succeeded
- if "[Boundary]" in output or "RNode" in output or "WiFi" in output:
+ if "[Boundary]" in output or "Node" in output or "WiFi" in output:
ser.close()
return True, output
except Exception:
@@ -1013,7 +1120,7 @@ def _monitor_boot(port, timeout=8):
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
- global _board
+ global _board, _detected_flash_size
parser = argparse.ArgumentParser(
description="RTNode-HeltecV4 Flash Utility — flash transport node firmware to Heltec V3/V4",
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -1090,8 +1197,24 @@ Examples:
_early_port = None
if args.board:
- # Explicit board — no detection needed
+ # Explicit board — keep the selected profile, but still probe the device
+ # when a port is available so flash size can override stale profile data.
_board = args.board
+ _early_port = args.port or find_serial_port()
+ if _early_port:
+ print(f"Reading flash info from {_early_port}...")
+ info, err = read_flash_info(_early_port, esptool_cmd)
+ if info:
+ detected_info = info
+ actual_flash = info.get("flash_size")
+ if actual_flash:
+ _detected_flash_size = actual_flash
+ print(f" Chip: {info.get('chip', '?')}")
+ print(f" Flash: {actual_flash or '?'}")
+ print(f" Features: {info.get('features', '?')}")
+ print(f" MAC: {info.get('mac', '?')}")
+ else:
+ print(f" Flash probe failed: {err}")
elif args.merge_only:
# No device needed for merge — fall back to default
_board = DEFAULT_BOARD
@@ -1109,8 +1232,11 @@ Examples:
if board_key:
_board = board_key
detected_info = info
+ actual_flash = info.get("flash_size")
+ if actual_flash:
+ _detected_flash_size = actual_flash
print(f" Chip: {info.get('chip', '?')}")
- print(f" Flash: {info.get('flash_size', '?')}")
+ print(f" Flash: {actual_flash or '?'}")
print(f" Features: {info.get('features', '?')}")
print(f" MAC: {info.get('mac', '?')}")
print(f" → Detected: {BOARD_PROFILES[board_key]['name']}")
@@ -1140,14 +1266,20 @@ Examples:
if args.flash_mode:
_flash_mode_override = args.flash_mode
+ fv = flash_variant()
+ print(f" Flash size: {FLASH_SIZE()}"
+ + (" (detected from device)" if _detected_flash_size else " (conservative default)"))
+ print(f" Variant: {fv['firmware_bin']}")
print(f" Flash mode: {BOARD_FLASH_MODE().upper()}"
+ (" (override)" if _flash_mode_override else " (board default)"))
# Determine firmware file
firmware_path = None
- merged_fn = MERGED_FILENAME()
+ # Local merged binary path: lives alongside the PIO build output
+ merged_fn = os.path.join(fv["build_dir"],
+ fv["firmware_bin"].replace(".bin", "_merged.bin"))
firmware_bin = FIRMWARE_BIN()
- pio_env = PIO_ENV()
+ pio_env = PIO_ENV()
if args.file:
firmware_path = args.file
@@ -1181,10 +1313,10 @@ Examples:
firmware_path = merged_fn
else:
# Try cache
- cached = _cached_firmware_path(_board)
+ cached = _extracted_firmware_path(fv["firmware_bin"])
if os.path.isfile(cached):
firmware_path = cached
- meta = _read_cache_meta(_board)
+ meta = _read_cache_meta()
print(f"Using cached firmware: {meta.get('tag', '?') if meta else '?'}")
else:
print("No firmware found for full flash!")
@@ -1198,7 +1330,7 @@ Examples:
else:
# Default path: fetch from GitHub (unless --offline)
if not args.offline:
- fw_path, tag_or_err = fetch_firmware(_board, release_tag=args.release)
+ fw_path, tag_or_err = fetch_firmware(_board, FLASH_SIZE(), release_tag=args.release)
if fw_path:
firmware_path = fw_path
print(f"\n Release: {tag_or_err}")
@@ -1212,10 +1344,10 @@ Examples:
firmware_path = firmware_bin
print(f"Using local PlatformIO build: {firmware_bin}")
else:
- cached = _cached_firmware_path(_board)
+ cached = _extracted_firmware_path(fv["firmware_bin"])
if os.path.isfile(cached):
firmware_path = cached
- meta = _read_cache_meta(_board)
+ meta = _read_cache_meta()
print(f"Using cached firmware: {meta.get('tag', '?') if meta else '?'}")
elif os.path.isfile(merged_fn):
firmware_path = merged_fn
diff --git a/lib/microReticulum/src/Bytes.h b/lib/microReticulum/src/Bytes.h
index 120e1e3..2f92363 100755
--- a/lib/microReticulum/src/Bytes.h
+++ b/lib/microReticulum/src/Bytes.h
@@ -439,7 +439,10 @@ namespace ArduinoJson {
}
// Deserialize
inline void convertFromJson(JsonVariantConst src, RNS::Bytes& dst) {
- dst.assignHex(src.as<const char*>());
+ const char* hex = src.as<const char*>();
+ if (hex != nullptr) {
+ dst.assignHex(hex);
+ }
}
inline bool canConvertFromJson(JsonVariantConst src, const RNS::Bytes&) {
return src.is<const char*>();
diff --git a/lib/microReticulum/src/Transport.cpp b/lib/microReticulum/src/Transport.cpp
index df8f4fc..9e08a24 100755
--- a/lib/microReticulum/src/Transport.cpp
+++ b/lib/microReticulum/src/Transport.cpp
@@ -3690,6 +3690,12 @@ will announce it.
DestinationEntry& destination_entry = (*destination_iter).second;
const Packet& announce_packet = destination_entry.announce_packet();
const Bytes& next_hop = destination_entry._received_from;
+ if (!announce_packet) {
+ // Cache file missing or corrupt — remove the stale entry and bail
+ WARNING("path_request: removing stale path to " + destination_hash.toHex() + " due to missing announce packet cache");
+ _destination_table.erase(destination_iter);
+ return;
+ }
const Interface& receiving_interface = destination_entry.receiving_interface();
if (attached_interface.mode() == Type::Interface::MODE_ROAMING && attached_interface == receiving_interface) {
diff --git a/platformio.ini b/platformio.ini
index b42b09c..47f1c30 100755
--- a/platformio.ini
+++ b/platformio.ini
@@ -315,20 +315,16 @@ board = heltec_wifi_lora_32_V3
custom_variant = heltec32v3
board_build.filesystem = littlefs
; Flash / memory layout for 8MB flash
-; PSRAM: V3 ESP32-S3FN8 has NO PSRAM — firmware detects this at runtime
-; and falls back to internal SRAM for TLSF pool.
-; BOARD_HAS_PSRAM tells Arduino to *attempt* psramInit(); harmless if absent.
+; PSRAM: V3 ESP32-S3FN8 has NO PSRAM — do NOT set BOARD_HAS_PSRAM or psram_type
+; The firmware's runtime detection (ESP.getPsramSize()==0) disables TLSF and uses malloc().
board_upload.flash_size = 8MB
board_upload.maximum_size = 8388608
board_build.partitions = default_8MB.csv
-board_build.flash_mode = qio
-board_build.psram_type = qio
-board_build.arduino.memory_type = qio_qspi
-monitor_speed = 921600
+board_build.flash_mode = dio
+monitor_speed = 115200
build_flags =
${env.build_flags}
-DBOARD_MODEL=BOARD_HELTEC32_V3
- -DBOARD_HAS_PSRAM=1
-DBOUNDARY_MODE
;-DNDEBUG
-DRNS_USE_TLSF=1
@@ -360,16 +356,16 @@ lib_deps =
[env:heltec_V4_boundary]
platform = espressif32
board = esp32-s3-devkitc-1
-custom_variant = heltec32v4_boundary
+custom_variant = heltec32v4_boundary_8mb
board_build.filesystem = littlefs
-; Flash / memory layout for 16MB flash + 2MB PSRAM
-board_upload.flash_size = 16MB
-board_upload.maximum_size = 16777216
-board_build.partitions = default_16MB.csv
+; Flash / memory layout for 8MB flash + 2MB PSRAM
+board_upload.flash_size = 8MB
+board_upload.maximum_size = 8388608
+board_build.partitions = default_8MB.csv
board_build.flash_mode = qio
board_build.psram_type = qio
board_build.arduino.memory_type = qio_qspi
-monitor_speed = 921600
+monitor_speed = 115200
build_flags =
${env.build_flags}
-DBOARD_MODEL=BOARD_HELTEC32_V4
@@ -392,6 +388,34 @@ lib_deps =
XPowersLib@^0.2.1
monitor_filters = esp32_exception_decoder
+[env:heltec_V4_boundary_16mb]
+platform = espressif32
+board = esp32-s3-devkitc-1
+custom_variant = heltec32v4_boundary_16mb
+board_build.filesystem = littlefs
+; Flash / memory layout for 16MB flash + 2MB PSRAM
+board_upload.flash_size = 16MB
+board_upload.maximum_size = 16777216
+board_build.partitions = default_16MB.csv
+board_build.flash_mode = qio
+board_build.psram_type = qio
+board_build.arduino.memory_type = qio_qspi
+monitor_speed = 115200
+build_flags =
+ ${env.build_flags}
+ -DBOARD_MODEL=BOARD_HELTEC32_V4
+ -DARDUINO_USB_CDC_ON_BOOT=1
+ -DBOARD_HAS_PSRAM=1
+ -DBOUNDARY_MODE
+ -DRNS_USE_TLSF=1
+ -DRNS_USE_ALLOCATOR=1
+ -DBOUNDARY_TCP_MODE=0
+ -DBOUNDARY_TCP_PORT=4242
+lib_deps =
+ ${env.lib_deps}
+ XPowersLib@^0.2.1
+monitor_filters = esp32_exception_decoder
+
[env:heltec_V4_boundary-local]
platform = espressif32
board = esp32-s3-devkitc-1
Served by rngit 1.4.2 - Generated in 0.05s